// deck.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import LayerManager from './layer-manager';
import ViewManager from './view-manager';
import MapView from '../views/map-view';
import EffectManager from './effect-manager';
import DeckRenderer from './deck-renderer';
import DeckPicker from './deck-picker';
import {Widget} from './widget';
import {WidgetManager} from './widget-manager';
import {TooltipWidget} from './tooltip-widget';
import log from '../utils/log';
import {deepEqual} from '../utils/deep-equal';
import typedArrayManager from '../utils/typed-array-manager';
import {VERSION} from './init';

import {luma} from '@luma.gl/core';
import {webgl2Adapter} from '@luma.gl/webgl';
import {Timeline} from '@luma.gl/engine';
import {AnimationLoop} from '@luma.gl/engine';
import {GL} from '@luma.gl/constants';
import type {CanvasContextProps, Device, DeviceProps, Framebuffer, Parameters} from '@luma.gl/core';
import type {ShaderModule} from '@luma.gl/shadertools';

import {Stats} from '@probe.gl/stats';
import {EventManager} from 'mjolnir.js';

import assert from '../utils/assert';
import {EVENT_HANDLERS, RECOGNIZERS, RecognizerOptions} from './constants';

import type {Effect} from './effect';
import type {FilterContext} from '../passes/layers-pass';
import type Layer from './layer';
import type View from '../views/view';
import type Viewport from '../viewports/viewport';
import type {EventManagerOptions, MjolnirGestureEvent, MjolnirPointerEvent} from 'mjolnir.js';
import type {TypedArrayManagerOptions} from '../utils/typed-array-manager';
import type {ViewStateChangeParameters, InteractionState} from '../controllers/controller';
import type {PickingInfo} from './picking/pick-info';
import type {PickByPointOptions, PickByRectOptions} from './deck-picker';
import type {LayersList} from './layer-manager';
import type {TooltipContent} from './tooltip-widget';
import type {ViewStateMap, AnyViewStateOf, ViewOrViews, ViewStateObject} from './view-manager';
import {CreateDeviceProps} from '@luma.gl/core';

/* global document */

// eslint-disable-next-line @typescript-eslint/no-empty-function
function noop() {}

const getCursor = ({isDragging}) => (isDragging ? 'grabbing' : 'grab');

export type DeckMetrics = {
  fps: number;
  setPropsTime: number;
  updateAttributesTime: number;
  framesRedrawn: number;
  pickTime: number;
  pickCount: number;
  gpuTime: number;
  gpuTimePerFrame: number;
  cpuTime: number;
  cpuTimePerFrame: number;
  bufferMemory: number;
  textureMemory: number;
  renderbufferMemory: number;
  gpuMemory: number;
};

type CursorState = {
  /** Whether the cursor is over a pickable object */
  isHovering: boolean;
  /** Whether the cursor is down */
  isDragging: boolean;
};

export type DeckProps<ViewsT extends ViewOrViews = null> = {
  /** Id of this Deck instance */
  id?: string;
  /** Width of the canvas, a number in pixels or a valid CSS string.
   * @default `'100%'`
   */
  width?: string | number | null;
  /** Height of the canvas, a number in pixels or a valid CSS string.
   * @default `'100%'`
   */
  height?: string | number | null;
  /** Additional CSS styles for the canvas. */
  style?: Partial<CSSStyleDeclaration> | null;

  /** Controls the resolution of drawing buffer used for rendering.
   * @default `true` (use browser devicePixelRatio)
   */
  useDevicePixels?: boolean | number;
  /** Extra pixels around the pointer to include while picking.
   * @default `0`
   */
  pickingRadius?: number;

  /** WebGL parameters to be set before each frame is rendered. */
  parameters?: Parameters;
  /** If supplied, will be called before a layer is drawn to determine whether it should be rendered. */
  layerFilter?: ((context: FilterContext) => boolean) | null;

  /** The container to append the auto-created canvas to.
   * @default `document.body`
   */
  parent?: HTMLDivElement | null;

  /** The canvas to render into.
   * Can be either a HTMLCanvasElement or the element id.
   * Will be auto-created if not supplied.
   */
  canvas?: HTMLCanvasElement | string | null;

  /** Use an existing luma.gl GPU device. @note If not supplied, a new device will be created using props.deviceProps */
  device?: Device | null;

  /** A new device will be created using these props, assuming that an existing device is not supplied using props.device) */
  deviceProps?: CreateDeviceProps;

  /** WebGL context @deprecated Use props.deviceProps.webgl. Also note that preserveDrawingBuffers is true by default */
  gl?: WebGL2RenderingContext | null;

  /**
   * The array of Layer instances to be rendered.
   * Nested arrays are accepted, as well as falsy values (`null`, `false`, `undefined`)
   */
  layers?: LayersList;
  /** The array of effects to be rendered. A lighting effect will be added if an empty array is supplied. */
  effects?: Effect[];
  /** A single View instance, or an array of `View` instances.
   * @default `new MapView()`
   */
  views?: ViewsT;
  /** Options for viewport interactivity, e.g. pan, rotate and zoom with mouse, touch and keyboard.
   * This is a shorthand for defining interaction with the `views` prop if you are using the default view (i.e. a single `MapView`)
   */
  controller?: View['props']['controller'];
  /**
   * An object that describes the view state for each view in the `views` prop.
   * Use if the camera state should be managed external to the `Deck` instance.
   */
  viewState?: ViewStateMap<ViewsT> | null;
  /**
   * If provided, the `Deck` instance will track camera state changes automatically,
   * with `initialViewState` as its initial settings.
   */
  initialViewState?: ViewStateMap<ViewsT> | null;

  /** Allow browser default touch actions.
   * @default `'none'`
   */
  touchAction?: EventManagerOptions['touchAction'];
  /**
   * Optional mjolnir.js recognizer options
   */
  eventRecognizerOptions?: RecognizerOptions;

  /** (Experimental) Render to a custom frame buffer other than to screen. */
  _framebuffer?: Framebuffer | null;
  /** (Experimental) Forces deck.gl to redraw layers every animation frame. */
  _animate?: boolean;
  /** (Experimental) If set to `false`, force disables all picking features, disregarding the `pickable` prop set in any layer. */
  _pickable?: boolean;
  /** (Experimental) Fine-tune attribute memory usage. See documentation for details. */
  _typedArrayManagerProps?: TypedArrayManagerOptions;
  /** An array of Widget instances to be added to the parent element. */
  widgets?: Widget[];

  /** Called once the GPU Device has been initiated. */
  onDeviceInitialized?: (device: Device) => void;
  /** @deprecated Called once the WebGL context has been initiated. */
  onWebGLInitialized?: (gl: WebGL2RenderingContext) => void;
  /** Called when the canvas resizes. */
  onResize?: (dimensions: {width: number; height: number}) => void;
  /** Called when the user has interacted with the deck.gl canvas, e.g. using mouse, touch or keyboard. */
  onViewStateChange?: <ViewStateT extends AnyViewStateOf<ViewsT>>(
    params: ViewStateChangeParameters<ViewStateT>
  ) => ViewStateT | null | void;
  /** Called when the user has interacted with the deck.gl canvas, e.g. using mouse, touch or keyboard. */
  onInteractionStateChange?: (state: InteractionState) => void;
  /** Called just before the canvas rerenders. */
  onBeforeRender?: (context: {device: Device; gl: WebGL2RenderingContext}) => void;
  /** Called right after the canvas rerenders. */
  onAfterRender?: (context: {device: Device; gl: WebGL2RenderingContext}) => void;
  /** Called once after gl context and all Deck components are created. */
  onLoad?: () => void;
  /** Called if deck.gl encounters an error.
   * If this callback is set to `null`, errors are silently ignored.
   * @default `console.error`
   */
  onError?: ((error: Error, layer?: Layer) => void) | null;
  /** Called when the pointer moves over the canvas. */
  onHover?: ((info: PickingInfo, event: MjolnirPointerEvent) => void) | null;
  /** Called when clicking on the canvas. */
  onClick?: ((info: PickingInfo, event: MjolnirGestureEvent) => void) | null;
  /** Called when the user starts dragging on the canvas. */
  onDragStart?: ((info: PickingInfo, event: MjolnirGestureEvent) => void) | null;
  /** Called when dragging the canvas. */
  onDrag?: ((info: PickingInfo, event: MjolnirGestureEvent) => void) | null;
  /** Called when the user releases from dragging the canvas. */
  onDragEnd?: ((info: PickingInfo, event: MjolnirGestureEvent) => void) | null;

  /** (Experimental) Replace the default redraw procedure */
  _customRender?: ((reason: string) => void) | null;
  /** (Experimental) Called once every second with performance metrics. */
  _onMetrics?: ((metrics: DeckMetrics) => void) | null;

  /** A custom callback to retrieve the cursor type. */
  getCursor?: (state: CursorState) => string;
  /** Callback that takes a hovered-over point and renders a tooltip. */
  getTooltip?: ((info: PickingInfo) => TooltipContent) | null;

  /** (Debug) Flag to enable WebGL debug mode. Requires importing `@luma.gl/debug`. */
  debug?: boolean;
  /** (Debug) Render the picking buffer to screen. */
  drawPickingColors?: boolean;
};

const defaultProps: DeckProps = {
  id: '',
  width: '100%',
  height: '100%',
  style: null,
  viewState: null,
  initialViewState: null,
  pickingRadius: 0,
  layerFilter: null,
  parameters: {},
  parent: null,
  device: null,
  deviceProps: {} as DeviceProps,
  gl: null,
  canvas: null,
  layers: [],
  effects: [],
  views: null,
  controller: null, // Rely on external controller, e.g. react-map-gl
  useDevicePixels: true,
  touchAction: 'none',
  eventRecognizerOptions: {},
  _framebuffer: null,
  _animate: false,
  _pickable: true,
  _typedArrayManagerProps: {},
  _customRender: null,
  widgets: [],

  onDeviceInitialized: noop,
  onWebGLInitialized: noop,
  onResize: noop,
  onViewStateChange: noop,
  onInteractionStateChange: noop,
  onBeforeRender: noop,
  onAfterRender: noop,
  onLoad: noop,
  onError: (error: Error) => log.error(error.message, error.cause)(),
  onHover: null,
  onClick: null,
  onDragStart: null,
  onDrag: null,
  onDragEnd: null,
  _onMetrics: null,

  getCursor,
  getTooltip: null,

  debug: false,
  drawPickingColors: false
};

/* eslint-disable max-statements */
export default class Deck<ViewsT extends ViewOrViews = null> {
  static defaultProps = defaultProps;
  // This is used to defeat tree shaking of init.js
  // https://github.com/visgl/deck.gl/issues/3213
  static VERSION = VERSION;

  readonly props: Required<DeckProps<ViewsT>>;
  readonly width: number = 0;
  readonly height: number = 0;
  // Allows attaching arbitrary data to the instance
  readonly userData: Record<string, any> = {};

  protected device: Device | null = null;

  protected canvas: HTMLCanvasElement | null = null;
  protected viewManager: ViewManager<View[]> | null = null;
  protected layerManager: LayerManager | null = null;
  protected effectManager: EffectManager | null = null;
  protected deckRenderer: DeckRenderer | null = null;
  protected deckPicker: DeckPicker | null = null;
  protected eventManager: EventManager | null = null;
  protected widgetManager: WidgetManager | null = null;
  protected tooltip: TooltipWidget | null = null;
  protected animationLoop: AnimationLoop | null = null;

  /** Internal view state if no callback is supplied */
  protected viewState: ViewStateObject<ViewsT> | null;
  protected cursorState: CursorState = {
    isHovering: false,
    isDragging: false
  };

  protected stats = new Stats({id: 'deck.gl'});
  protected metrics: DeckMetrics = {
    fps: 0,
    setPropsTime: 0,
    updateAttributesTime: 0,
    framesRedrawn: 0,
    pickTime: 0,
    pickCount: 0,
    gpuTime: 0,
    gpuTimePerFrame: 0,
    cpuTime: 0,
    cpuTimePerFrame: 0,
    bufferMemory: 0,
    textureMemory: 0,
    renderbufferMemory: 0,
    gpuMemory: 0
  };
  private _metricsCounter: number = 0;

  private _needsRedraw: false | string = 'Initial render';
  private _pickRequest: {
    mode: string;
    event: MjolnirPointerEvent | null;
    x: number;
    y: number;
    radius: number;
  } = {
    mode: 'hover',
    x: -1,
    y: -1,
    radius: 0,
    event: null
  };

  /**
   * Pick and store the object under the pointer on `pointerdown`.
   * This object is reused for subsequent `onClick` and `onDrag*` callbacks.
   */
  private _lastPointerDownInfo: PickingInfo | null = null;

  constructor(props: DeckProps<ViewsT>) {
    // @ts-ignore views
    this.props = {...defaultProps, ...props};
    props = this.props;

    if (props.viewState && props.initialViewState) {
      log.warn(
        'View state tracking is disabled. Use either `initialViewState` for auto update or `viewState` for manual update.'
      )();
    }
    this.viewState = this.props.initialViewState;

    // See if we already have a device
    if (props.device) {
      this.device = props.device;
    }

    let deviceOrPromise: Device | Promise<Device> | null = this.device;

    // Attach a new luma.gl device to a WebGL2 context if supplied
    if (!deviceOrPromise && props.gl) {
      if (props.gl instanceof WebGLRenderingContext) {
        log.error('WebGL1 context not supported.')();
      }
      // Preserve user's callbacks and add resize handling
      const userOnResize = this.props.deviceProps?.onResize;

      deviceOrPromise = webgl2Adapter.attach(props.gl, {
        ...this.props.deviceProps,
        onResize: (canvasContext, info) => {
          // Manually sync drawing buffer dimensions (canvas is externally managed)
          // TODO(v9.3): Use canvasContext.setDrawingBufferSize(width, height) when upgrading to luma 9.3+
          const {width, height} = canvasContext.canvas;
          // @ts-ignore - accessing public properties to sync state
          canvasContext.drawingBufferWidth = width;
          // @ts-ignore
          canvasContext.drawingBufferHeight = height;

          this._needsRedraw = 'Canvas resized';
          userOnResize?.(canvasContext, info);
        }
      });
    }

    // Create a new device
    if (!deviceOrPromise) {
      deviceOrPromise = this._createDevice(props);
    }

    this.animationLoop = this._createAnimationLoop(deviceOrPromise, props);

    this.setProps(props);

    // UNSAFE/experimental prop: only set at initialization to avoid performance hit
    if (props._typedArrayManagerProps) {
      typedArrayManager.setOptions(props._typedArrayManagerProps);
    }

    this.animationLoop.start();
  }

  /** Stop rendering and dispose all resources */
  finalize() {
    this.animationLoop?.stop();
    this.animationLoop?.destroy();
    this.animationLoop = null;
    this._lastPointerDownInfo = null;

    this.layerManager?.finalize();
    this.layerManager = null;

    this.viewManager?.finalize();
    this.viewManager = null;

    this.effectManager?.finalize();
    this.effectManager = null;

    this.deckRenderer?.finalize();
    this.deckRenderer = null;

    this.deckPicker?.finalize();
    this.deckPicker = null;

    this.eventManager?.destroy();
    this.eventManager = null;

    this.widgetManager?.finalize();
    this.widgetManager = null;

    if (!this.props.canvas && !this.props.device && !this.props.gl && this.canvas) {
      // remove internally created canvas
      this.canvas.parentElement?.removeChild(this.canvas);
      this.canvas = null;
    }
  }

  /** Partially update props */
  setProps(props: DeckProps<ViewsT>): void {
    this.stats.get('setProps Time').timeStart();

    if ('onLayerHover' in props) {
      log.removed('onLayerHover', 'onHover')();
    }
    if ('onLayerClick' in props) {
      log.removed('onLayerClick', 'onClick')();
    }
    if (
      props.initialViewState &&
      // depth = 3 when comparing viewStates: viewId.position.0
      !deepEqual(this.props.initialViewState, props.initialViewState, 3)
    ) {
      // Overwrite internal view state
      this.viewState = props.initialViewState;
    }

    // Merge with existing props
    Object.assign(this.props, props);

    // Update CSS size of canvas
    this._setCanvasSize(this.props);

    // We need to overwrite CSS style width and height with actual, numeric values
    const resolvedProps: Required<DeckProps> & {
      width: number;
      height: number;
      views: View[];
      viewState: ViewStateObject<ViewsT> | null;
    } = Object.create(this.props);
    Object.assign(resolvedProps, {
      views: this._getViews(),
      width: this.width,
      height: this.height,
      viewState: this._getViewState()
    });

    if (props.device && props.device.id !== this.device?.id) {
      this.animationLoop?.stop();
      if (this.canvas !== props.device.canvasContext?.canvas) {
        // remove old canvas if new one being used and de-register events
        // TODO (ck): We might not own this canvas depending it's source, so removing it from the
        // DOM here might be a bit unexpected but it should be ok for most users.
        this.canvas?.remove();
        this.eventManager?.destroy();

        // ensure we will re-attach ourselves after createDevice callbacks
        this.canvas = null;
      }

      log.log(`recreating animation loop for new device! id=${props.device.id}`)();

      this.animationLoop = this._createAnimationLoop(props.device, props);
      this.animationLoop.start();
    }

    // Update the animation loop
    this.animationLoop?.setProps(resolvedProps);

    if (props.useDevicePixels !== undefined && this.device?.canvasContext) {
      this.device.canvasContext.setProps({useDevicePixels: props.useDevicePixels});
    }

    // If initialized, update sub manager props
    if (this.layerManager) {
      this.viewManager!.setProps(resolvedProps);
      // Make sure that any new layer gets initialized with the current viewport
      this.layerManager.activateViewport(this.getViewports()[0]);
      this.layerManager.setProps(resolvedProps);
      this.effectManager!.setProps(resolvedProps);
      this.deckRenderer!.setProps(resolvedProps);
      this.deckPicker!.setProps(resolvedProps);
      this.widgetManager!.setProps(resolvedProps);
    }

    this.stats.get('setProps Time').timeEnd();
  }

  // Public API

  /**
   * Check if a redraw is needed
   * @returns `false` or a string summarizing the redraw reason
   */
  needsRedraw(
    opts: {
      /** Reset the redraw flag afterwards. Default `true` */
      clearRedrawFlags: boolean;
    } = {clearRedrawFlags: false}
  ): false | string {
    if (!this.layerManager) {
      // Not initialized or already finalized
      return false;
    }
    if (this.props._animate) {
      return 'Deck._animate';
    }

    let redraw: false | string = this._needsRedraw;

    if (opts.clearRedrawFlags) {
      this._needsRedraw = false;
    }

    const viewManagerNeedsRedraw = this.viewManager!.needsRedraw(opts);
    const layerManagerNeedsRedraw = this.layerManager.needsRedraw(opts);
    const effectManagerNeedsRedraw = this.effectManager!.needsRedraw(opts);
    const deckRendererNeedsRedraw = this.deckRenderer!.needsRedraw(opts);

    redraw =
      redraw ||
      viewManagerNeedsRedraw ||
      layerManagerNeedsRedraw ||
      effectManagerNeedsRedraw ||
      deckRendererNeedsRedraw;
    return redraw;
  }

  /**
   * Redraw the GL context
   * @param reason If not provided, only redraw if deemed necessary. Otherwise redraw regardless of internal states.
   * @returns
   */
  redraw(reason?: string): void {
    if (!this.layerManager) {
      // Not yet initialized
      return;
    }
    // Check if we need to redraw
    let redrawReason = this.needsRedraw({clearRedrawFlags: true});
    // User-supplied should take precedent, however the redraw flags get cleared regardless
    redrawReason = reason || redrawReason;

    if (!redrawReason) {
      return;
    }

    this.stats.get('Redraw Count').incrementCount();
    if (this.props._customRender) {
      this.props._customRender(redrawReason);
    } else {
      this._drawLayers(redrawReason);
    }
  }

  /** Flag indicating that the Deck instance has initialized its resources and it's safe to call public methods. */
  get isInitialized(): boolean {
    return this.viewManager !== null;
  }

  /** Get a list of views that are currently rendered */
  getViews(): View[] {
    assert(this.viewManager);
    return this.viewManager.views;
  }

  /** Get a list of viewports that are currently rendered.
   * @param rect If provided, only returns viewports within the given bounding box.
   */
  getViewports(rect?: {x: number; y: number; width?: number; height?: number}): Viewport[] {
    assert(this.viewManager);
    return this.viewManager.getViewports(rect);
  }

  /** Get the current canvas element. */
  getCanvas(): HTMLCanvasElement | null {
    return this.canvas;
  }

  /** Query the object rendered on top at a given point */
  pickObject(opts: {
    /** x position in pixels */
    x: number;
    /** y position in pixels */
    y: number;
    /** Radius of tolerance in pixels. Default `0`. */
    radius?: number;
    /** A list of layer ids to query from. If not specified, then all pickable and visible layers are queried. */
    layerIds?: string[];
    /** If `true`, `info.coordinate` will be a 3D point by unprojecting the `x, y` screen coordinates onto the picked geometry. Default `false`. */
    unproject3D?: boolean;
  }): PickingInfo | null {
    const infos = this._pick('pickObject', 'pickObject Time', opts).result;
    return infos.length ? infos[0] : null;
  }

  /* Query all rendered objects at a given point */
  pickMultipleObjects(opts: {
    /** x position in pixels */
    x: number;
    /** y position in pixels */
    y: number;
    /** Radius of tolerance in pixels. Default `0`. */
    radius?: number;
    /** Specifies the max number of objects to return. Default `10`. */
    depth?: number;
    /** A list of layer ids to query from. If not specified, then all pickable and visible layers are queried. */
    layerIds?: string[];
    /** If `true`, `info.coordinate` will be a 3D point by unprojecting the `x, y` screen coordinates onto the picked geometry. Default `false`. */
    unproject3D?: boolean;
  }): PickingInfo[] {
    opts.depth = opts.depth || 10;
    return this._pick('pickObject', 'pickMultipleObjects Time', opts).result;
  }

  /* Query all objects rendered on top within a bounding box */
  pickObjects(opts: {
    /** Left of the bounding box in pixels */
    x: number;
    /** Top of the bounding box in pixels */
    y: number;
    /** Width of the bounding box in pixels. Default `1` */
    width?: number;
    /** Height of the bounding box in pixels. Default `1` */
    height?: number;
    /** A list of layer ids to query from. If not specified, then all pickable and visible layers are queried. */
    layerIds?: string[];
    /** If specified, limits the number of objects that can be returned. */
    maxObjects?: number | null;
  }): PickingInfo[] {
    return this._pick('pickObjects', 'pickObjects Time', opts);
  }

  /** Experimental
   * Add a global resource for sharing among layers
   */
  _addResources(
    resources: {
      [id: string]: any;
    },
    forceUpdate = false
  ) {
    for (const id in resources) {
      this.layerManager!.resourceManager.add({resourceId: id, data: resources[id], forceUpdate});
    }
  }

  /** Experimental
   * Remove a global resource
   */
  _removeResources(resourceIds: string[]) {
    for (const id of resourceIds) {
      this.layerManager!.resourceManager.remove(id);
    }
  }

  /** Experimental
   * Register a default effect. Effects will be sorted by order, those with a low order will be rendered first
   */
  _addDefaultEffect(effect: Effect) {
    this.effectManager!.addDefaultEffect(effect);
  }

  _addDefaultShaderModule(module: ShaderModule<Record<string, unknown>>) {
    this.layerManager!.addDefaultShaderModule(module);
  }

  _removeDefaultShaderModule(module: ShaderModule<Record<string, unknown>>) {
    this.layerManager?.removeDefaultShaderModule(module);
  }

  // Private Methods

  private _pick(
    method: 'pickObject',
    statKey: string,
    opts: PickByPointOptions & {layerIds?: string[]}
  ): {
    result: PickingInfo[];
    emptyInfo: PickingInfo;
  };
  private _pick(
    method: 'pickObjects',
    statKey: string,
    opts: PickByRectOptions & {layerIds?: string[]}
  ): PickingInfo[];

  private _pick(
    method: 'pickObject' | 'pickObjects',
    statKey: string,
    opts: (PickByPointOptions | PickByRectOptions) & {layerIds?: string[]}
  ) {
    assert(this.deckPicker);

    const {stats} = this;

    stats.get('Pick Count').incrementCount();
    stats.get(statKey).timeStart();

    const infos = this.deckPicker[method]({
      // layerManager, viewManager and effectManager are always defined if deckPicker is
      layers: this.layerManager!.getLayers(opts),
      views: this.viewManager!.getViews(),
      viewports: this.getViewports(opts),
      onViewportActive: this.layerManager!.activateViewport,
      effects: this.effectManager!.getEffects(),
      ...opts
    });

    stats.get(statKey).timeEnd();

    return infos;
  }

  /** Resolve props.canvas to element */
  private _createCanvas(props: DeckProps<ViewsT>): HTMLCanvasElement {
    let canvas = props.canvas;

    // TODO EventManager should accept element id
    if (typeof canvas === 'string') {
      canvas = document.getElementById(canvas) as HTMLCanvasElement;
      assert(canvas);
    }

    if (!canvas) {
      canvas = document.createElement('canvas');
      canvas.id = props.id || 'deckgl-overlay';

      // TODO this is a hack, investigate why these are not set for the picking
      // tests
      if (props.width && typeof props.width === 'number') {
        canvas.width = props.width;
      }
      if (props.height && typeof props.height === 'number') {
        canvas.height = props.height;
      }
      const parent = props.parent || document.body;
      parent.appendChild(canvas);
    }

    Object.assign(canvas.style, props.style);

    return canvas;
  }

  /** Updates canvas width and/or height, if provided as props */
  private _setCanvasSize(props: Required<DeckProps<ViewsT>>): void {
    if (!this.canvas) {
      return;
    }

    const {width, height} = props;
    // Set size ONLY if props are being provided, otherwise let canvas be layouted freely
    if (width || width === 0) {
      const cssWidth = Number.isFinite(width) ? `${width}px` : (width as string);
      this.canvas.style.width = cssWidth;
    }
    if (height || height === 0) {
      const cssHeight = Number.isFinite(height) ? `${height}px` : (height as string);
      // Note: position==='absolute' required for height 100% to work
      this.canvas.style.position = props.style?.position || 'absolute';
      this.canvas.style.height = cssHeight;
    }
  }

  /** If canvas size has changed, reads out the new size and update */
  private _updateCanvasSize(): void {
    const {canvas} = this;
    if (!canvas) {
      return;
    }
    // Fallback to width/height when clientWidth/clientHeight are undefined (OffscreenCanvas).
    const newWidth = canvas.clientWidth ?? canvas.width;
    const newHeight = canvas.clientHeight ?? canvas.height;
    if (newWidth !== this.width || newHeight !== this.height) {
      // @ts-expect-error private assign to read-only property
      this.width = newWidth;
      // @ts-expect-error private assign to read-only property
      this.height = newHeight;
      this.viewManager?.setProps({width: newWidth, height: newHeight});
      // Make sure that any new layer gets initialized with the current viewport
      this.layerManager?.activateViewport(this.getViewports()[0]);
      this.props.onResize({width: newWidth, height: newHeight});
    }
  }

  private _createAnimationLoop(
    deviceOrPromise: Device | Promise<Device>,
    props: DeckProps<ViewsT>
  ): AnimationLoop {
    const {
      // width,
      // height,
      gl,
      // debug,
      onError
      // onBeforeRender,
      // onAfterRender,
    } = props;

    return new AnimationLoop({
      device: deviceOrPromise,
      // TODO v9
      autoResizeDrawingBuffer: !gl, // do not auto resize external context
      autoResizeViewport: false,
      // @ts-expect-error luma.gl needs to accept Promise<void> return value
      onInitialize: context => this._setDevice(context.device),
      onRender: this._onRenderFrame.bind(this),
      // @ts-expect-error typing mismatch: AnimationLoop does not accept onError:null
      onError

      // onBeforeRender,
      // onAfterRender,
    });
  }

  // Create a device from the deviceProps, assigning required defaults
  private _createDevice(props: DeckProps<ViewsT>): Promise<Device> {
    const canvasContextUserProps = this.props.deviceProps?.createCanvasContext;
    const canvasContextProps =
      typeof canvasContextUserProps === 'object' ? canvasContextUserProps : undefined;

    // In deck.gl v9, Deck always bundles and adds a webgl2Adapter.
    // This behavior is expected to change in deck.gl v10 to support WebGPU only builds.
    const deviceProps = {adapters: [], ...props.deviceProps};
    if (!deviceProps.adapters.includes(webgl2Adapter)) {
      deviceProps.adapters.push(webgl2Adapter);
    }

    const defaultCanvasProps: CanvasContextProps = {
      // we must use 'premultiplied' canvas for webgpu to enable transparency and match shaders
      alphaMode: this.props.deviceProps?.type === 'webgpu' ? 'premultiplied' : undefined
    };

    // Preserve user's onResize callback
    const userOnResize = this.props.deviceProps?.onResize;

    // Create the "best" device supported from the registered adapters
    return luma.createDevice({
      // luma by default throws if a device is already attached
      // asynchronous device creation could happen after finalize() is called
      // TODO - createDevice should support AbortController?
      _reuseDevices: true,
      // tests can't handle WebGPU devices yet so we force WebGL2 unless overridden
      type: 'webgl',
      ...deviceProps,
      // In deck.gl v10 we may emphasize multi canvas support and unwind this prop wrapping
      createCanvasContext: {
        ...defaultCanvasProps,
        ...canvasContextProps,
        canvas: this._createCanvas(props),
        useDevicePixels: this.props.useDevicePixels,
        autoResize: true
      },
      onResize: (canvasContext, info) => {
        // Set redraw flag when luma.gl's CanvasContext detects a resize
        // This restores pre-9.2 behavior where resize automatically triggered redraws
        this._needsRedraw = 'Canvas resized';
        // Call user's onResize if provided
        userOnResize?.(canvasContext, info);
      }
    });
  }

  // Get the most relevant view state: props.viewState, if supplied, shadows internal viewState
  // TODO: For backwards compatibility ensure numeric width and height is added to the viewState
  private _getViewState(): ViewStateObject<ViewsT> | null {
    return this.props.viewState || this.viewState;
  }

  // Get the view descriptor list
  private _getViews(): View[] {
    const {views} = this.props;
    const normalizedViews: View[] = Array.isArray(views)
      ? views
      : // If null, default to a full screen map view port
        views
        ? [views]
        : [new MapView({id: 'default-view'})];
    if (normalizedViews.length && this.props.controller) {
      // Backward compatibility: support controller prop
      normalizedViews[0].props.controller = this.props.controller;
    }
    return normalizedViews;
  }

  private _onContextLost() {
    const {onError} = this.props;
    if (this.animationLoop && onError) {
      onError(new Error('WebGL context is lost'));
    }
  }

  // The `pointermove` event may fire multiple times in between two animation frames,
  // it's a waste of time to run picking without rerender. Instead we save the last pick
  // request and only do it once on the next animation frame.
  /** Internal use only: event handler for pointerdown */
  _onPointerMove = (event: MjolnirPointerEvent) => {
    const {_pickRequest} = this;
    if (event.type === 'pointerleave') {
      _pickRequest.x = -1;
      _pickRequest.y = -1;
      _pickRequest.radius = 0;
    } else if (event.leftButton || event.rightButton) {
      // Do not trigger onHover callbacks if mouse button is down.
      return;
    } else {
      const pos = event.offsetCenter;
      // Do not trigger callbacks when click/hover position is invalid. Doing so will cause a
      // assertion error when attempting to unproject the position.
      if (!pos) {
        return;
      }
      _pickRequest.x = pos.x;
      _pickRequest.y = pos.y;
      _pickRequest.radius = this.props.pickingRadius;
    }

    if (this.layerManager) {
      this.layerManager.context.mousePosition = {x: _pickRequest.x, y: _pickRequest.y};
    }

    _pickRequest.event = event;
  };

  /** Actually run picking */
  private _pickAndCallback() {
    if (this.device?.type === 'webgpu') {
      return;
    }

    const {_pickRequest} = this;

    if (_pickRequest.event) {
      // Perform picking
      const {result, emptyInfo} = this._pick('pickObject', 'pickObject Time', _pickRequest);
      this.cursorState.isHovering = result.length > 0;

      // There are 4 possible scenarios:
      // result is [outInfo, pickedInfo] (moved from one pickable layer to another)
      // result is [outInfo] (moved outside of a pickable layer)
      // result is [pickedInfo] (moved into or over a pickable layer)
      // result is [] (nothing is or was picked)
      //
      // `layer.props.onHover` should be called on all affected layers (out/over)
      // `deck.props.onHover` should be called with the picked info if any, or empty info otherwise
      // `deck.props.getTooltip` should be called with the picked info if any, or empty info otherwise

      // Execute callbacks
      let pickedInfo = emptyInfo;
      let handled = false;
      for (const info of result) {
        pickedInfo = info;
        handled = info.layer?.onHover(info, _pickRequest.event) || handled;
      }
      if (!handled) {
        this.props.onHover?.(pickedInfo, _pickRequest.event);
        this.widgetManager!.onHover(pickedInfo, _pickRequest.event);
      }

      // Clear pending pickRequest
      _pickRequest.event = null;
    }
  }

  private _updateCursor(): void {
    const container = this.props.parent || this.canvas;
    if (container) {
      container.style.cursor = this.props.getCursor(this.cursorState);
    }
  }

  private _setDevice(device: Device) {
    this.device = device;

    if (!this.animationLoop) {
      // finalize() has been called
      return;
    }

    // if external context...
    if (!this.canvas) {
      this.canvas = this.device.canvasContext?.canvas as HTMLCanvasElement;

      // external canvas may not be in DOM
      if (!this.canvas.isConnected && this.props.parent) {
        this.props.parent.insertBefore(this.canvas, this.props.parent.firstChild);
      }
      // TODO v9
      // ts-expect-error - Currently luma.gl v9 does not expose these options
      // All WebGLDevice contexts are instrumented, but it seems the device
      // should have a method to start state tracking even if not enabled?
      // instrumentGLContext(this.device.gl, {enable: true, copyState: true});
    }

    if (this.device.type === 'webgl') {
      this.device.setParametersWebGL({
        blend: true,
        blendFunc: [GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA, GL.ONE, GL.ONE_MINUS_SRC_ALPHA],
        polygonOffsetFill: true,
        depthTest: true,
        depthFunc: GL.LEQUAL
      });
    }

    this.props.onDeviceInitialized(this.device);
    if (this.device.type === 'webgl') {
      // Legacy callback - warn?
      // @ts-expect-error gl is not visible on Device base class
      this.props.onWebGLInitialized(this.device.gl);
    }

    // timeline for transitions
    const timeline = new Timeline();
    timeline.play();
    this.animationLoop.attachTimeline(timeline);

    this.eventManager = new EventManager(this.props.parent || this.canvas, {
      touchAction: this.props.touchAction,
      recognizers: Object.keys(RECOGNIZERS).map((eventName: string) => {
        // Resolve recognizer settings
        const [RecognizerConstructor, defaultOptions, recognizeWith, requestFailure] =
          RECOGNIZERS[eventName];
        const optionsOverride = this.props.eventRecognizerOptions?.[eventName];
        const options = {...defaultOptions, ...optionsOverride, event: eventName};
        return {
          recognizer: new RecognizerConstructor(options),
          recognizeWith,
          requestFailure
        };
      }),
      events: {
        pointerdown: this._onPointerDown,
        pointermove: this._onPointerMove,
        pointerleave: this._onPointerMove
      }
    });
    for (const eventType in EVENT_HANDLERS) {
      this.eventManager.on(eventType, this._onEvent);
    }

    this.viewManager = new ViewManager({
      timeline,
      eventManager: this.eventManager,
      onViewStateChange: this._onViewStateChange.bind(this),
      onInteractionStateChange: this._onInteractionStateChange.bind(this),
      views: this._getViews(),
      viewState: this._getViewState(),
      width: this.width,
      height: this.height
    });

    // viewManager must be initialized before layerManager
    // layerManager depends on viewport created by viewManager.
    const viewport = this.viewManager.getViewports()[0];

    // Note: avoid React setState due GL animation loop / setState timing issue
    this.layerManager = new LayerManager(this.device, {
      deck: this,
      stats: this.stats,
      viewport,
      timeline
    });

    this.effectManager = new EffectManager({
      deck: this,
      device: this.device
    });

    this.deckRenderer = new DeckRenderer(this.device);

    this.deckPicker = new DeckPicker(this.device);

    this.widgetManager = new WidgetManager({
      deck: this,
      parentElement: this.canvas?.parentElement
    });
    this.widgetManager.addDefault(new TooltipWidget());

    this.setProps(this.props);

    this._updateCanvasSize();
    this.props.onLoad();
  }

  /** Internal only: default render function (redraw all layers and views) */
  _drawLayers(
    redrawReason: string,
    renderOptions?: {
      target?: Framebuffer;
      layerFilter?: (context: FilterContext) => boolean;
      layers?: Layer[];
      viewports?: Viewport[];
      views?: {[viewId: string]: View};
      pass?: string;
      effects?: Effect[];
      clearStack?: boolean;
      clearCanvas?: boolean;
    }
  ) {
    const {device, gl} = this.layerManager!.context;

    this.props.onBeforeRender({device, gl});

    const opts = {
      target: this.props._framebuffer,
      layers: this.layerManager!.getLayers(),
      viewports: this.viewManager!.getViewports(),
      onViewportActive: this.layerManager!.activateViewport,
      views: this.viewManager!.getViews(),
      pass: 'screen',
      effects: this.effectManager!.getEffects(),
      ...renderOptions
    };
    this.deckRenderer?.renderLayers(opts);

    if (opts.pass === 'screen') {
      // This method could be called when drawing to picking buffer, texture etc.
      // Only when drawing to screen, update all widgets (UI components)
      this.widgetManager!.onRedraw({
        viewports: opts.viewports,
        layers: opts.layers
      });
    }

    this.props.onAfterRender({device, gl});
  }

  // Callbacks

  private _onRenderFrame() {
    this._getFrameStats();

    // Log perf stats every second
    if (this._metricsCounter++ % 60 === 0) {
      this._getMetrics();
      this.stats.reset();
      log.table(4, this.metrics)();

      // Experimental: report metrics
      if (this.props._onMetrics) {
        this.props._onMetrics(this.metrics);
      }
    }

    this._updateCanvasSize();

    this._updateCursor();

    // Update layers if needed (e.g. some async prop has loaded)
    // Note: This can trigger a redraw
    this.layerManager!.updateLayers();

    // Perform picking request if any
    // TODO(ibgreen): Picking not yet supported on WebGPU
    if (this.device?.type !== 'webgpu') {
      this._pickAndCallback();
    }

    // Redraw if necessary
    this.redraw();

    // Update viewport transition if needed
    // Note: this can trigger `onViewStateChange`, and affect layers
    // We want to defer these changes to the next frame
    if (this.viewManager) {
      this.viewManager.updateViewStates();
    }
  }

  // Callbacks

  private _onViewStateChange(params: ViewStateChangeParameters & {viewId: string}) {
    // Let app know that view state is changing, and give it a chance to change it
    const viewState = this.props.onViewStateChange(params) || params.viewState;

    // If initialViewState was set on creation, auto track position
    if (this.viewState) {
      this.viewState = {...this.viewState, [params.viewId]: viewState};
      if (!this.props.viewState) {
        // Apply internal view state
        if (this.viewManager) {
          this.viewManager.setProps({viewState: this.viewState});
        }
      }
    }
  }

  private _onInteractionStateChange(interactionState: InteractionState) {
    this.cursorState.isDragging = interactionState.isDragging || false;
    this.props.onInteractionStateChange(interactionState);
  }

  /** Internal use only: event handler for click & drag */
  _onEvent = (event: MjolnirGestureEvent) => {
    const eventHandlerProp = EVENT_HANDLERS[event.type];
    const pos = event.offsetCenter;

    if (!eventHandlerProp || !pos || !this.layerManager) {
      return;
    }

    // Reuse last picked object
    const layers = this.layerManager.getLayers();
    const info = this.deckPicker!.getLastPickedObject(
      {
        x: pos.x,
        y: pos.y,
        layers,
        viewports: this.getViewports(pos)
      },
      this._lastPointerDownInfo
    ) as PickingInfo;

    const {layer} = info;
    const layerHandler = layer && (layer[eventHandlerProp] || layer.props[eventHandlerProp]);
    const rootHandler = this.props[eventHandlerProp];
    let handled = false;

    if (layerHandler) {
      handled = layerHandler.call(layer, info, event);
    }
    if (!handled) {
      rootHandler?.(info, event);
      this.widgetManager!.onEvent(info, event);
    }
  };

  /** Internal use only: evnet handler for pointerdown */
  _onPointerDown = (event: MjolnirPointerEvent) => {
    // TODO(ibgreen) Picking not yet supported on WebGPU
    if (this.device?.type === 'webgpu') {
      return;
    }
    const pos = event.offsetCenter;
    const pickedInfo = this._pick('pickObject', 'pickObject Time', {
      x: pos.x,
      y: pos.y,
      radius: this.props.pickingRadius
    });
    this._lastPointerDownInfo = pickedInfo.result[0] || pickedInfo.emptyInfo;
  };

  private _getFrameStats(): void {
    const {stats} = this;
    stats.get('frameRate').timeEnd();
    stats.get('frameRate').timeStart();

    // Get individual stats from luma.gl so reset works
    const animationLoopStats = this.animationLoop!.stats;
    stats.get('GPU Time').addTime(animationLoopStats.get('GPU Time').lastTiming);
    stats.get('CPU Time').addTime(animationLoopStats.get('CPU Time').lastTiming);
  }

  private _getMetrics(): void {
    const {metrics, stats} = this;
    metrics.fps = stats.get('frameRate').getHz();
    metrics.setPropsTime = stats.get('setProps Time').time;
    metrics.updateAttributesTime = stats.get('Update Attributes').time;
    metrics.framesRedrawn = stats.get('Redraw Count').count;
    metrics.pickTime =
      stats.get('pickObject Time').time +
      stats.get('pickMultipleObjects Time').time +
      stats.get('pickObjects Time').time;
    metrics.pickCount = stats.get('Pick Count').count;

    // Luma stats
    metrics.gpuTime = stats.get('GPU Time').time;
    metrics.cpuTime = stats.get('CPU Time').time;
    metrics.gpuTimePerFrame = stats.get('GPU Time').getAverageTime();
    metrics.cpuTimePerFrame = stats.get('CPU Time').getAverageTime();

    const memoryStats = luma.stats.get('Memory Usage');
    metrics.bufferMemory = memoryStats.get('Buffer Memory').count;
    metrics.textureMemory = memoryStats.get('Texture Memory').count;
    metrics.renderbufferMemory = memoryStats.get('Renderbuffer Memory').count;
    metrics.gpuMemory = memoryStats.get('GPU Memory').count;
  }
}
